Skip to content

Add the virtual↔host PathMapping core with two facades over it#17

Merged
odrobnik merged 6 commits into
mainfrom
path-mapping-core
Jun 11, 2026
Merged

Add the virtual↔host PathMapping core with two facades over it#17
odrobnik merged 6 commits into
mainfrom
path-mapping-core

Conversation

@odrobnik

@odrobnik odrobnik commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

What

The mapping + confinement core for cooperative sandboxing (Cocoanetics/SwiftBash#83), moved down from SwiftBash so every caller shares one authority:

  • PathMapping — the mount table. Longest-virtual-prefix routing, lexical ./.. collapse (via Shell.normalizePath, moved down from SwiftBash's interpreter so both normalise identically), and virtualPath(forHost:) for folding host paths back to the script-visible spelling.
  • Sandbox.confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:) — the gate over a mapping: a file URL authorizes iff its canonical (symlink-resolved) path lands inside one of the mapping's host roots. Carries the mapping as Sandbox.pathMapping. Region directories anchor at the home mount's host root; temporaryDirectory defaults to the /tmp mount's host root.
  • Shell.resolve(_:) now translates through sandbox?.pathMapping after the CWD join — SwiftPorts CLIs / the JS runtime get host paths ready for Foundation/C I/O, gated by the same table, with no per-tool rewrite. Shell.currentDirectory translates identically. No mapping → no behavior change (standalone CLIs, rooted/appContainer sandboxes are untouched).
  • Shell.displayPath(for:) — the outbound door: fold host paths back to virtual for anything a script gets to see (realpath hygiene).

Why

Sandbox enforcement has two cooperative jobs — translation and confinement — and the translation half lived only inside SwiftBash's MountedFileSystem. Every cooperative caller that isn't a bash builtin resolved paths in the wrong space: Shell.resolve returned virtual spellings that were then fed to FileManager/C APIs, and the URL gate checked those virtual spellings against host roots, passing only by string coincidence. One core, two doors (the FileSystem protocol stays in SwiftBash as Facade A; resolve() is Facade B) is what keeps the two sides from disagreeing about where /tmp/foo lives.

Merge order

Merge this first. Cocoanetics/SwiftBash and Cocoanetics/SwiftPorts pin ShellKit branch: main; their companion PRs build on this and their CI stays red until this lands:

  • SwiftBash: "sandbox: one PathMapping core, two facades (fixes #83)"
  • SwiftPorts: "Fold displayed absolute paths back to virtual under a path mapping"

Also in this PR

  • Environment.synthetic now includes /usr/local/bin in its default PATH — SwiftBash catalogues the SwiftPorts CLIs (fd, rg, …) under that virtual tier, and omitting it made them "command not found" in exactly the sandboxed runs they exist for (found by end-to-end smoke testing the SwiftBash PR).

Tests

14 new tests in PathMappingTests (translation both directions, longest-prefix rules, .. collapse before routing, Shell.resolve/displayPath under a mapping, gate containment incl. symlink escape + symlink-spelled mount roots, region/temp derivation, network policy). Full suite: 56/56.

🤖 Generated with Claude Code

Sandbox enforcement has two cooperative jobs - translation and
confinement - and until now the translation half lived only inside
SwiftBash's MountedFileSystem. Every cooperative caller that isn't a
bash builtin (SwiftPorts CLIs, the JS runtime, SwiftScript) resolved
paths in the wrong space: Shell.resolve returned virtual spellings it
then fed to FileManager/C APIs, and the URL gate checked those virtual
spellings against host roots. Cocoanetics/SwiftBash#83 moves the core
down here so both facades share one authority:

- PathMapping: the mount table (longest-virtual-prefix routing,
  lexical `..` collapse via the new Shell.normalizePath) translating
  virtual -> host, plus virtualPath(forHost:) folding host paths back
  to virtual for display so realpath-style output never leaks the
  embedder's host layout.
- Sandbox.confined(to:home:temporaryDirectory:...): the Facade-B gate.
  Authorizes a file URL iff its canonical (symlink-resolved) path
  lands inside one of the mapping's host roots; carries the mapping as
  Sandbox.pathMapping. Region directories anchor at the `home` mount's
  host root; temporaryDirectory defaults to the `/tmp` mount's host.
- Shell.resolve now translates through sandbox?.pathMapping after the
  cwd join, so SwiftPorts CLIs / JS / SwiftScript get host paths that
  do real I/O on the right files with no per-tool rewrite; the same
  table gates them, so resolution and confinement cannot disagree.
  Shell.currentDirectory translates identically. Without a mapping
  (standalone CLIs, rooted/appContainer sandboxes) nothing changes.
- Shell.displayPath(for:): the outbound door for anything a script
  gets to see.

Shell.normalizePath is the lexical normaliser SwiftBash carried; it
moves down so the mapping (and the subclass) share one implementation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 688c937625

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/ShellKit/Shell.swift Outdated
odrobnik added 2 commits June 11, 2026 12:53
SwiftLint CI: file_length on Sandbox+Factories (confined(to:) moves to
its own file, sharing the now-internal authorizeUnderRoots),
type_body_length on PathMappingTests (the confined-gate suite moves to
SandboxConfinedTests), and large_tuple in virtualPath(forHost:) (the
best-match accumulator becomes a small local struct).
SwiftBash catalogues the SwiftPorts CLIs (fd, rg, yq, the swift-js /
node surface) under the virtual /usr/local/bin tier, and its own
runtime env default is /usr/local/bin:/usr/bin:/bin. The synthetic
environment - used by exactly the sandboxed runs those ports are
registered for - omitted that tier, so `fd` came back "command not
found" inside `swift-bash exec --sandbox` while jq (catalogued under
/usr/bin) worked. Align the synthetic PATH with the catalogue.
odrobnik added 3 commits June 11, 2026 13:24
Codex P1 on #17: under a mapping, resolve returned paths that matched
no mount unchanged - so a script holding (or guessing) the HOST
spelling of a mounted directory could address files through it, and
the gate authorized it since the location genuinely lies inside a
canonical host root. The containment boundary held; the namespace
boundary (scripts speak virtual only) didn't - and Facade B
disagreed with the bash-side mounted filesystem, which ENOENTs host
spellings.

resolve now never hands back un-translated text under a mapping:
paths outside every mount (including the Windows drive-letter and
empty-cwd fallbacks) resolve to a voided location under
Shell.unmappedPathSentinel ("/dev/null/unmapped" + the normalised
virtual spelling). /dev/null is a file on every POSIX host, so
nothing below it can exist or be created, and the gate's containment
check rejects it like any other out-of-roots path. displayPath folds
the sentinel back to the original spelling so diagnostics stay
readable. Tests cover the host-spelling probe (verbatim and
symlink-resolved), gate denial, and ..-escape of the sentinel.
Shell.swift crossed the 400-line file_length cap with the sentinel
addition; the resolve/displayPath/sentinel family lives with
normalizePath now, which is the better home anyway.
On Linux the platform temp root IS /tmp, so a fixture host dir under
it prefix-matches a virtual /tmp mount and translates as an ordinary
virtual path (into the mount's own backing - harmless by design)
instead of voiding. Probe with non-colliding virtual prefixes so the
no-mount-matches contract is what's actually exercised on every
platform; document the collision semantics in the test.
@odrobnik odrobnik merged commit 82ae2bc into main Jun 11, 2026
6 checks passed
@odrobnik odrobnik deleted the path-mapping-core branch June 11, 2026 11:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant